[SwiftUI] TimelineViewとCanvasでパーティクルをたくさん描画する!

[SwiftUI] TimelineViewとCanvasでパーティクルをたくさん描画する!

Clock Icon2024.09.06

こんにちは。きんくまです。
SwiftUI勉強中です

つくったもの

https://www.youtube.com/shorts/uOqJMgiLvBU

機能

  • Canvasをドラッグすることで、背景の色を変化させる
  • Canvasをドラッグさせたときに、まわりに泡のパーティクルを発生させる
  • パーティクルはサインカーブを描きながら上に上がっていく
  • 現在のフレームレート(FPS)と泡の数を表示する

240906_timeline_canvas_perticle1

240906_timeline_canvas_perticle2

240906_timeline_canvas_perticle3

240906_timeline_canvas_perticle4

ソースコード

/// 泡
struct Bubble {
    /// X座標
    var x: CGFloat {
        // 開始地点からサインカーブで横に移動させる
        startX + sin(radian) * xRadius
    }
    /// X座標の開始地点
    var startX: CGFloat
    /// X座標をサインカーブでゆらすときの半径
    var xRadius: CGFloat
    /// Y座標
    var y: CGFloat
    /// 速度Y
    let vy: CGFloat
    /// サインカーブするときのラジアン
    var radian: CGFloat
    /// 泡を描画するときの半径
    var radius: CGFloat
    /// 透明度(開始時に0にして、ふわっと出現させる)
    var opacity: CGFloat = 0
    /// 透明度の最大値
    let maxOpacity: CGFloat

    /// 泡をランダム性をもたせながら作成する
    static func makeBubble(x: CGFloat, y: CGFloat) -> Bubble {
        let startX = x + CGFloat.random(in: -1 ... 1) * 10
        let xRadius = CGFloat.random(in: 20 ... 50)
        let y = y + CGFloat.random(in: -1 ... 1) * 10
        let vy = CGFloat.random(in: -4 ... -1)
        let radian = CGFloat.random(in: 0 ..< CGFloat.pi * 2)
        let radius = 30 * CGFloat.random(in: 0.05 ... 1)
        let maxOpacity = CGFloat.random(in: 0.2 ... 1)
        return Bubble(startX: startX, xRadius: xRadius, y: y, vy: vy, radian: radian, radius: radius, maxOpacity: maxOpacity)
    }

    /// データを更新する
    mutating func update() {
        radian += 0.03
        // 2πを超えたら0からまわすように戻す
        if radian > CGFloat.pi * 2 {
            radian = radian - CGFloat.pi * 2
        }
        // Y座標は速度Yを足す
        y += vy
        // 透明度をアニメーションさせながら一定の数値まであげる
        if opacity <= maxOpacity {
            opacity += 0.03
        }
    }
}

/// モデル
@Observable class ViewModel {
    /// 背景色の色相
    var backGroundColorHue: CGFloat = 0.5
    @ObservationIgnored private var previousBackGroundColorHue: CGFloat = 0.5
    /// 背景のグラデーション
    var backgroundGradient: Gradient {
        let startColor = Color(hue: backGroundColorHue - 0.2, saturation: 1, brightness: 1)
        let centerColor = Color(hue: backGroundColorHue , saturation: 0.9, brightness: 0.6)
        let endColor = Color(hue: backGroundColorHue + 0.1, saturation: 0.7, brightness: 0.2)
        return Gradient(colors: [startColor, centerColor, endColor])
    }
    /// 泡たち
    var bubbles: [Bubble] = []

    /// 前回の更新日時
    var previousUpdateDate = Date()

    /// フレームレート
    var fps: Int = 0

    /// ドラッグ時
    func updateOnDragChange(_ value: DragGesture.Value, canvasSize: CGSize) {
        // Y方向のドラッグの値によって背景の色相を変更する
        let dy = value.location.y - value.startLocation.y
        // 画面の高さに応じて0から1に正規化する(扱いやすくするため)
        let normalizedDy = dy / canvasSize.height
        var newBackGroundColorBaseHue = previousBackGroundColorHue + normalizedDy * 0.6
        if newBackGroundColorBaseHue < 0 {
            newBackGroundColorBaseHue = 1 + newBackGroundColorBaseHue
        } else if newBackGroundColorBaseHue > 1 {
            newBackGroundColorBaseHue = 1 - newBackGroundColorBaseHue
        }
        backGroundColorHue = newBackGroundColorBaseHue

        // 一定の確率でドラッグ周辺に泡を追加する
        if Float.random(in: 0..<1) < 0.7 {
            let newBubble = Bubble.makeBubble(x: value.location.x, y: value.location.y)
            bubbles.append(newBubble)
        }
        // 泡が少なかったから確率低めにしてもう少し足してみる
        if Float.random(in: 0..<1) < 0.4 {
            let newBubble = Bubble.makeBubble(x: value.location.x, y: value.location.y)
            bubbles.append(newBubble)
        }
    }

    /// ドラッグ終了時
    func updateOnDragEnd() {
        previousBackGroundColorHue = backGroundColorHue
    }

    /// データの更新
    func update(now: Date) {
        // 途中で配列から削除することもあるので、reversedで大->小でまわす
        // そうしないと配列の範囲外になってクラッシュする
        for i in (0 ..< bubbles.count).reversed() {
            // データを更新
            var bubble = bubbles[i]
            bubble.update()
            // 画面の外で見えなくなったら配列から削除
            if bubble.y < -(bubble.radius * 2) {
                bubbles.remove(at: i)
            // 通常は元のデータと入れ替える
            } else {
                bubbles[i] = bubble
            }
        }
        updateFPS(now: now)
    }

    /// フレームレートを更新
    func updateFPS(now: Date) {
        let fpsDouble = 1 / now.timeIntervalSince(previousUpdateDate)
        fps = Int(floor(fpsDouble))
        previousUpdateDate = now
    }
}

struct ContentView: View {
    /// モデル
    @State private var viewModel = ViewModel()

    var body: some View {
        TimelineView(.animation) { timelineContext in

            GeometryReader { geometory in
                ZStack {
                    Canvas { canvasContext, size in
                        // 描画
                        drawCanvas(geometory: geometory, canvasContext: canvasContext, size: size)
                        // データの更新。メインスレッドで行う
                        Task.detached { @MainActor in
                            viewModel.update(now: timelineContext.date)
                        }
                    }
                    .ignoresSafeArea()
                    .gesture(onCanvasDragGesturue(canvasSize: geometory.size))

                    // フレームレート(FPS)と泡の数の表示
                    VStack(alignment: .leading, spacing: 0) {
                        Text("FPS: \(viewModel.fps),  Bubbles: \(viewModel.bubbles.count)")
                            .foregroundColor(.white)
                            .font(.system(size: 21))
                            .fontWeight(.bold)
                            .padding(.leading, 20)
                            .frame(maxWidth: .infinity, alignment: .leading)
                        Spacer()
                    }
                }
            }
        }
    }

    /// Canvasドラッグ時
    func onCanvasDragGesturue(canvasSize: CGSize) -> some Gesture {
        // 基準座標を.globalにするとSafeAreaがあっても一番上が0になる
        // .localだとSafeAreaから上はマイナス座標になる
        DragGesture(coordinateSpace: .global)
            .onChanged { value in
                viewModel.updateOnDragChange(value, canvasSize: canvasSize)
            }
            .onEnded { value in
                viewModel.updateOnDragEnd()
            }
    }

    /// Canvasを描画する
    func drawCanvas(geometory: GeometryProxy, canvasContext: GraphicsContext, size: CGSize) {

        // 背景の描画
        canvasContext.fill(
            Path(roundedRect: CGRect(origin: .zero, size: size), cornerRadius: 0),
            with: .linearGradient(viewModel.backgroundGradient, startPoint: .zero, endPoint: .init(x: 0, y: size.height))
        )
        // 泡の描画
        viewModel.bubbles.forEach { bubble in
            canvasContext.fill(Path(ellipseIn: CGRect(x: bubble.x - bubble.radius, y: bubble.y - bubble.radius, width: bubble.radius * 2, height: bubble.radius * 2)), with: .color(Color(white: 1, opacity: bubble.opacity)))
        }
    }
}

説明

経緯

Canvasをリアルタイムに更新して描画できないのかな?と思い調べてみました。
そうしたら、こちらのページが見つかりました

SwiftUI Animated Canvas

やっていることとしては、CanvasとTimelineViewを組み合わせて更新するというものになっていました。

ですので同じようにできるか、試してみました

全体構成

  • TimelineViewの中にCanvasを設置
  • その中で以下を毎フレームごとに行います
    • 描画
    • データの更新
  • データの更新はメインスレッドの非同期処理で行います(そうじゃないと画面が固まった!)
    Task.detached { @MainActor in

背景の描画

  • 背景の色はHSB形式で指定
  • H(Hue=色相)を変更することで色を変えている
  • 色相は普通は360度で表されて、0度と360度はつながっている輪の構造。=色相環
  • ただしSwiftUIでは 0-360ではなく0-1になっている
  • 0より下になったときは1に戻る。1より上になったときは0に戻るように処理している

  • ドラッグ中にドラッグ位置の周辺に一定確率で泡を発生させる
  • そのとき配列にデータを追加
  • 毎フレームごとに泡のデータを更新
  • サインカーブは0-2πのラジアンを基準に動かすので、2πを超えたら0に戻す
  • 画面の外に見えなくなったら、配列から削除する

フレームレート(FPS)の計算

  • タイムラインから更新するたびにdateが取得できる
  • ひとつ前のフレームとの差分を使ってFPSを計算

感想とか

  • 常に60fpsが出ているので、思ったよりも描画負荷は低い
  • SwiftUIでもこのくらいのことができるんだなーと思いました

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.